COMPONENTS · v2.8+
ANDROIDX LIFECYCLE
ViewModel
Architecture
ViewModel is more than a class that survives rotation. Understanding its full architecture — the Store, the Owner, the Factory chain, and the scoping system — turns it from a magic box into a tool you truly control.
Why ViewModel exists
Before ViewModel, every configuration change (screen rotation, split-screen resize, locale change) destroyed and recreated your Activity or Fragment. UI-related data had to be serialized into a Bundle, which was limited to primitives and Parcelables, and re-fetched from disk/network after every rotation. A loading spinner, an in-progress network call, a 500-item list — all lost.
ViewModel solves a single, specific problem: survive configuration changes without serializing data through a Bundle. Everything else it does — scoping data to UI, separating concerns — is a consequence of this core capability.
The precise contract: A ViewModel is created when its owning scope first requests it. It survives any number of configuration changes to that scope. It is destroyed — and its onCleared() called — only when the scope is permanently finished: the Activity is finishing (not rotating), the Fragment is removed from the back stack, or the NavGraph scope is popped.
The ViewModel Lifecycle
The most important thing to internalize: a ViewModel's lifetime is longer than the lifetime of any single Activity or Fragment instance. Its lifetime is scoped to the logical existence of that UI — all rotations included — not to a single object instance.
onCleared() — the final callback
This is the ViewModel's only lifecycle callback. It fires exactly once, when the owning scope is permanently done. Use it to cancel coroutines that were started outside viewModelScope — though you should rarely need this since viewModelScope auto-cancels in onCleared().
class MainViewModel : ViewModel() { // viewModelScope is automatically cancelled in onCleared() init { viewModelScope.launch { repo.streamUpdates().collect { update -> _state.update { it.copy(data = update) } } } } // Rare — only for non-viewModelScope resources private val bluetoothListener = BluetoothListener() override fun onCleared() { super.onCleared() // Called ONCE, only when scope is permanently destroyed // NOT called on rotation bluetoothListener.unregister() } }
addCloseable(Closeable): Added in lifecycle 2.5. Attach any Closeable resource — it will be automatically closed in onCleared(). Cleaner than overriding onCleared() for resource cleanup.
ViewModelStore
This is the mechanism that makes ViewModel survival possible. ViewModelStore is a simple in-memory map that holds ViewModel instances, keyed by a canonical string. It lives attached to the ViewModelStoreOwner — and the system ensures the Store is carried across configuration changes while the Owner is recreated.
MODEL_KEY:com.app.MainViewModel"
MODEL_KEY:com.app.ProfileViewModel"
// Simplified from AndroidX source — it really is this simple class ViewModelStore { private val map = HashMap<String, ViewModel>() fun put(key: String, viewModel: ViewModel) { val oldVM = map.put(key, viewModel) oldVM?.onCleared() // clear replaced VM } fun get(key: String): ViewModel? = map[key] fun keys(): Set<String> = map.keys.toHashSet() // Called by the owner when it's truly finishing fun clear() { for (vm in map.values) vm.onCleared() map.clear() } } // The key format used by ViewModelProvider private const val DEFAULT_KEY = "androidx.lifecycle.ViewModelProvider.DefaultKey" // Full key: "$DEFAULT_KEY:${viewModelClass.canonicalName}"
How the Store survives rotation
ComponentActivity (the base of AppCompatActivity) implements a retention mechanism using NonConfigurationInstances. When Android destroys an Activity for a configuration change, it calls onRetainNonConfigurationInstance() before destruction. The returned object is preserved by the Activity runtime and passed back to the new Activity instance via getLastNonConfigurationInstance(). The ViewModelStore is stored inside this object.
detected
ConfigInstance()
called before destroy
saved in bundle
retained by system
destroyed &
recreated
NonConfigInstance()
called in onCreate
returned
same VMs inside
Fragment's mechanism: Fragments use a different approach — FragmentManagerViewModel, a ViewModel scoped to the parent (Activity or parent Fragment) that holds a map of child Fragment ViewModelStores. This nesting is what enables by activityViewModels() to return the Activity-scoped instance.
ViewModelStoreOwner
ViewModelStoreOwner is a single-method interface. Any class implementing it declares: "I own a ViewModelStore, and I manage its lifecycle." The implementer is responsible for clearing the store when its lifecycle ends permanently.
// The entire interface — just one property interface ViewModelStoreOwner { val viewModelStore: ViewModelStore } // ComponentActivity implements it like this: class ComponentActivity : ViewModelStoreOwner, ... { private var _viewModelStore: ViewModelStore? = null override val viewModelStore: ViewModelStore get() { if (_viewModelStore == null) { // Try to restore from non-config instance (rotation) val nc = lastNonConfigurationInstance if (nc is NonConfigurationInstances) { _viewModelStore = nc.viewModelStore } if (_viewModelStore == null) { _viewModelStore = ViewModelStore() // fresh start } } return _viewModelStore!! } override fun onDestroy() { super.onDestroy() if (!isChangingConfigurations()) { // Permanently finishing — clear the store viewModelStore.clear() } // If isChangingConfigurations() — store is kept! } }
All built-in ViewModelStoreOwners
isFinishing() is true in onDestroy — which is when the user navigates away, not when rotating. Survives: rotation, split-screen, multi-window resize, dark mode toggle.ViewModelStoreOwner. Its store is cleared when the Fragment is removed from the back stack and its onDestroy is called without being re-added. Note: going on the back stack does NOT clear the store.ViewModelStoreOwner. This enables sharing a ViewModel across multiple fragments within a navigation sub-graph — cleared only when the entry is popped off the back stack entirely.ViewModelStoreOwner on any class — a custom View, a service context wrapper, a Composable-backed scope — to get a ViewModel whose lifetime you control precisely.ViewModelProvider & Factories
ViewModelProvider is the gatekeeper. It bridges the request for a ViewModel with the Store and the Factory. Its job is simple: look up the key in the Store; if found, return the existing instance; if not, use the Factory to create one and put it in the Store.
// Simplified ViewModelProvider.get() logic fun <T : ViewModel> get(modelClass: KClass<T>): T { val canonicalName = modelClass.java.canonicalName ?: throw IllegalArgumentException("Local and anonymous classes can't be ViewModels") val key = "$DEFAULT_KEY:$canonicalName" return get(key, modelClass) } fun <T : ViewModel> get(key: String, modelClass: KClass<T>): T { val viewModel = store.get(key) if (modelClass.isInstance(viewModel)) { return viewModel as T // ← found in store, return existing } // Not found — create via factory val newVM = factory.create(modelClass, extras) store.put(key, newVM) return newVM } // Usage variants // 1. No-arg ViewModel (Hilt or reflection-based) val vm: MainViewModel by viewModels() // 2. Custom factory (manual DI) val vm: MainViewModel by viewModels { MainViewModelFactory(repository) } // 3. Custom key (multiple instances of same class) val vm1 = ViewModelProvider(this).get("player_1", PlayerViewModel::class) val vm2 = ViewModelProvider(this).get("player_2", PlayerViewModel::class)
The Factory hierarchy
When no explicit factory is provided, ViewModelProvider uses the application-level factory. Modern Hilt integration puts an HiltViewModelFactory here via the @AndroidEntryPoint annotation, enabling zero-boilerplate dependency injection.
AndroidViewModel(application) by passing the Application context automatically.SavedStateHandle injection. Created by ComponentActivity and Fragment as the default factory. Wraps whatever factory you provide.@AndroidEntryPoint. Delegates to the Hilt DI graph for construction — enables full constructor injection with @Inject.APPLICATION_KEY, SAVED_STATE_REGISTRY_OWNER_KEY, etc.class MyViewModelFactory( private val repo: UserRepository ) : ViewModelProvider.Factory { override fun <T : ViewModel> create( modelClass: Class<T>, extras: CreationExtras ): T { // Extract standard extras val application = extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]!! val savedStateHandle = extras.createSavedStateHandle() return when (modelClass) { MainViewModel::class.java -> MainViewModel(repo, savedStateHandle) as T else -> throw IllegalArgumentException("Unknown VM class") } } companion object { // The modern recommended factory pattern val Factory = viewModelFactory { initializer { val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]!! val handle = createSavedStateHandle() val repo = (app as MyApp).appComponent.userRepository MainViewModel(repo, handle) } } } }
Scoping Strategies
Every call to ViewModelProvider(owner)[MyViewModel::class] returns the same instance for that owner. Change the owner and you get a different (or new) ViewModel. This means ViewModel sharing is entirely a question of which owner you pass.
by activityViewModels()All Fragments in this Activity share one instance. Lifetime = until the Activity is truly finished. Use for data that must flow between unrelated sibling screens.
by navGraphViewModels(R.id.checkout_graph)Only Fragments within that sub-graph share the instance. Cleared when the entire sub-graph is popped. Perfect for multi-step flows (checkout, onboarding) where you need state across multiple screens.
by viewModels()Private to this Fragment. Not shared with siblings or parent. Cleared when the Fragment is permanently destroyed (removed and not re-added). The most common scope for single-screen ViewModels.
ViewModelProvider(customOwner)[MyVm::class]Pass any ViewModelStoreOwner. In Compose: use hiltViewModel() or create a custom owner for non-standard scoping. In tests: provide a fake owner to control ViewModel lifetime precisely.
// 1. Fragment-scoped (private to this fragment) val vm: ProfileViewModel by viewModels() // 2. Activity-scoped (shared with all siblings) val sharedVm: SharedViewModel by activityViewModels() // 3. NavGraph-scoped (shared within checkout flow) val checkoutVm: CheckoutViewModel by navGraphViewModels(R.id.checkout_graph) // 4. NavBackStackEntry-scoped (explicit destination) val entry = findNavController().getBackStackEntry(R.id.cartFragment) val cartVm: CartViewModel by viewModels({ entry }) // 5. Multiple instances of the same VM class val player1Vm = ViewModelProvider(this).get("player_1", PlayerViewModel::class.java) val player2Vm = ViewModelProvider(this).get("player_2", PlayerViewModel::class.java) // 6. Compose — hiltViewModel() uses LocalViewModelStoreOwner @Composable fun ProfileScreen() { val vm: ProfileViewModel = hiltViewModel() // NavEntry scope val sharedVm: SharedViewModel = hiltViewModel( // Activity scope viewModelStoreOwner = LocalContext.current as ViewModelStoreOwner ) }
Hilt Integration
Hilt's ViewModel integration is the modern standard. @HiltViewModel on the ViewModel class plus @Inject constructor enables full dependency injection with zero factory boilerplate. Hilt registers a custom ViewModelProvider.Factory through @AndroidEntryPoint that builds the DI graph for you.
// ViewModel — annotate with @HiltViewModel, inject with @Inject @HiltViewModel class OrderViewModel @Inject constructor( private val orderRepo: OrderRepository, private val userRepo: UserRepository, private val analytics: AnalyticsTracker, private val savedState: SavedStateHandle // auto-injected by Hilt ) : ViewModel() { val orders = orderRepo.getOrders() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) } // Fragment — just annotate with @AndroidEntryPoint @AndroidEntryPoint class OrderFragment : Fragment() { val viewModel: OrderViewModel by viewModels() // Hilt provides it } // Activity-scoped shared ViewModel @AndroidEntryPoint class CheckoutFragment : Fragment() { val cartVm: CartViewModel by activityViewModels() } // Assisted injection — for dynamic parameters at creation time @HiltViewModel(assistedFactory = DetailViewModel.Factory::class) class DetailViewModel @AssistedInject constructor( @Assisted val itemId: String, // provided at runtime private val repo: ItemRepository // from DI graph ) : ViewModel() { @AssistedFactory interface Factory { fun create(itemId: String): DetailViewModel } }
@Inject constructor with full DI graph access. Requires the owning Activity/Fragment to be @AndroidEntryPoint.HiltViewModelFactory as the default factory. This factory intercepts ViewModel creation requests and routes them through the Hilt DI graph when the class has @HiltViewModel.SavedStateHandle to any @HiltViewModel that requests it. No configuration needed — just add it to the constructor.SavedStateHandle
ViewModel survives rotation but not process death. When Android kills your process to reclaim memory, the entire JVM is gone — including the ViewModelStore. SavedStateHandle bridges this gap: it's backed by the same Bundle mechanism as onSaveInstanceState, so its contents survive process death and are restored when the user returns.
The rule of thumb: Everything your ViewModel needs to reconstruct the UI after process death should be in SavedStateHandle. Everything else — large datasets, lists, complex objects — stays in the ViewModel's normal fields (or in Room/DataStore for true persistence).
@HiltViewModel class SearchViewModel @Inject constructor( private val handle: SavedStateHandle, private val searchRepo: SearchRepository ) : ViewModel() { // Pattern 1: Simple get/set — survives rotation + process death var query: String get() = handle["query"] ?: "" set(v) { handle["query"] = v } // Pattern 2: StateFlow backed by SavedStateHandle // ← this is the recommended modern pattern val queryFlow: StateFlow<String> = handle.getStateFlow("query", "") // Setting updates the StateFlow AND persists to bundle fun onQueryChanged(q: String) { handle["query"] = q // triggers queryFlow emission } // Pattern 3: Drive a search from the persisted query val results: StateFlow<SearchState> = handle.getStateFlow("query", "") .debounce(300) .distinctUntilChanged() .flatMapLatest { q -> if (q.isBlank()) flowOf(SearchState.Empty) else searchRepo.search(q).map { SearchState.Results(it) } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SearchState.Empty) // Pattern 4: Reading Navigation arguments automatically // Navigation passes destination args to SavedStateHandle val orderId: String = handle.get<String>("orderId")!! // Or with the generated nav args type val args = OrderDetailArgs.fromSavedStateHandle(handle) }
| Storage | Survives rotation | Survives process death | Size limit | Best for |
|---|---|---|---|---|
| ViewModel fields | Yes | No | Unlimited | Lists, complex objects, in-flight calls |
| SavedStateHandle | Yes | Yes | ~1MB (Bundle) | Search query, scroll position, selected ID |
| SharedPreferences / DataStore | Yes | Yes | Unlimited | User preferences, settings |
| Room database | Yes | Yes | Device storage | Domain entities, offline data |
Patterns & Pitfalls
Never hold a Context in a ViewModel. Activities and Fragments are Contexts, and they're recreated on every rotation. A ViewModel holding a reference to an old Activity = memory leak guaranteed. If you need an Application context, use AndroidViewModel (which holds application: Application) or inject the Application via Hilt.
Never hold View or Fragment references in a ViewModel. Same reason — Views and Fragments are destroyed on rotation. The ViewModel observes data; the UI observes the ViewModel. The arrow of dependency always points upward, never down.
Always use viewModelScope for coroutines. It's tied to onCleared() and automatically cancelled. This prevents coroutine leaks from in-flight network calls after the user has navigated away.
Use StateFlow (not LiveData) for new code. StateFlow works correctly in Compose with collectAsStateWithLifecycle(), supports operators, and is Kotlin-native. LiveData is still fine for View-based apps but is no longer recommended for greenfield.
The complete production pattern
@HiltViewModel class ArticleViewModel @Inject constructor( private val repo: ArticleRepository, private val savedState: SavedStateHandle ) : ViewModel() { // ── State (UI observable) ────────────────────────────────── val uiState: StateFlow<ArticleUiState> = savedState.getStateFlow("filter", Filter.All) .flatMapLatest { filter -> repo.getArticles(filter) } .map { articles -> ArticleUiState.Success(articles) } .catch { e -> emit(ArticleUiState.Error(e.message)) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ArticleUiState.Loading) // ── One-shot events (navigation, snackbar) ───────────────── private val _events = MutableSharedFlow<ArticleEvent>(extraBufferCapacity = 1) val events = _events.asSharedFlow() // ── User actions ─────────────────────────────────────────── fun onFilterChanged(filter: Filter) { savedState["filter"] = filter } fun onArticleClicked(article: Article) { viewModelScope.launch { _events.emit(ArticleEvent.Navigate("articles/${article.id}")) } } fun onBookmarkClicked(article: Article) { viewModelScope.launch { repo.toggleBookmark(article.id) _events.tryEmit(ArticleEvent.ShowSnackbar("Bookmarked")) } } } // ── Sealed UI state ──────────────────────────────────────────── sealed class ArticleUiState { data object Loading : ArticleUiState() data class Success(val articles: List<Article>) : ArticleUiState() data class Error(val message: String?) : ArticleUiState() } // ── Fragment collection ──────────────────────────────────────── viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.uiState.collect { state -> when (state) { is ArticleUiState.Loading -> showLoading() is ArticleUiState.Success -> adapter.submitList(state.articles) is ArticleUiState.Error -> showError(state.message) } } } launch { viewModel.events.collect { event -> when (event) { is ArticleEvent.Navigate -> navigate(event.route) is ArticleEvent.ShowSnackbar -> showSnackbar(event.msg) } } } } }
Testing ViewModels
ViewModels are pure Kotlin — no Android framework dependencies (if you avoid AndroidViewModel). This makes them straightforward to unit test with coroutines and fakes.
@OptIn(ExperimentalCoroutinesApi::class) class ArticleViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() private val fakeRepo = FakeArticleRepository() private val savedState = SavedStateHandle() private lateinit var viewModel: ArticleViewModel @Before fun setup() { viewModel = ArticleViewModel(fakeRepo, savedState) } @Test fun `filter change loads correct articles`() = runTest { val states = mutableListOf<ArticleUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiState.toList(states) } viewModel.onFilterChanged(Filter.Bookmarked) assertEquals(Filter.Bookmarked, fakeRepo.lastFilter) job.cancel() } }